Description
Lightweight inline spellcheck: red wavy (undercurl) underline under misspelled words, with one-key suggest-and-replace. Catch obvious typos only — not an intense/morphological checker. Must stay zero-cgo so the Homebrew/go-install build keeps working, and must not bloat the binary much.
RENDERING (undercurl)
- Misspelled-word spans emit raw SGR: undercurl '\e[4:3m' + underline color '\e[58:2::R:G:Bm' (theme-driven red, e.g. Flexoki red 217,54,42), reset '\e[59;4:0m'.
- lipgloss/termenv DON'T expose undercurl or underline-color — add a raw-escape span attribute in the editor scanner. Preserve the markup-visible invariant: span text still concatenates to the raw line; only the SGR wrapper changes.
- Degrade gracefully: terminals without 4:3 show straight underline or ignore (Ghostty/kitty/WezTerm/foot/VTE support it). tmux needs terminal-features passthrough — document it.
DICTIONARY (lightweight, pure-Go)
- Embed a single common-English wordlist (SCOWL-derived, ~50-60k most-common words; gzip-embedded via embed.FS, decompressed into a hashset at startup). Target small added binary weight, not full coverage.
- No cgo, no hunspell — keeps 'go build .' / brew formula clean.
- Case-insensitive membership; treat possessives/simple plurals leniently if cheap.
SUGGEST & REPLACE
- Build a BK-tree from the same embedded dict at startup (edit-distance <=2 lookup; modest RAM, no extra binary weight; only queried on user trigger, never per render).
- Trigger key (suggest Ctrl+; TBD) when the cursor is on/adjacent to a flagged word opens a small popup of the top ~5 suggestions ranked by edit distance (tie-break by word frequency/length).
- Pick with arrows+Enter or a number key -> replace the misspelled word span in the buffer, mark dirty, clear its underline. Esc dismisses.
- The same popup includes an 'Add to dictionary' entry (writes the word to dict.txt) so add-word and replace share one UI.
WHAT TO SKIP (no false positives)
- Code fences and inline code, URLs, wikilinks [[...]], markdown link targets, YAML frontmatter values, and (when on in a code file) anything non-prose.
PERSONAL DICTIONARY (easy add)
- Plain file ~/.config/glint/dict.txt, one word per line, user-editable by hand.
- 'Add to dictionary' from the suggestion popup (or a direct add-word key) appends to dict.txt and clears the underline live.
TOGGLE / DEFAULTS
- On/off toggle (key TBD) for the session.
- Default ON for .md/.markdown/.txt and unnamed buffers; default OFF for recognized code-file extensions (driven by the same extension map as TASK-018 syntax highlighting — share it).
- Config key to override the default (e.g. spellcheck = auto|on|off).
PERF
- Check only visible-viewport words per render; cache word->ok results so typing doesn't re-check the whole doc each keystroke; invalidate a word's cache entry when added to the personal dict. Suggestion lookups run only on trigger.
Acceptance Criteria
- #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline
- #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work
- #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged
- #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works
- #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle
- #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline
- #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor
Implementation Plan
Decisions: dict=common ~60k (Norvig-freq ∩ curated words_alpha, frequency-ranked, rejects common typos); key=single Alt+; popup hub (suggest/toggle/add/ignore); mouse click on flagged word also opens popup.
Slices (each TDD): A. internal/spell: embed words.txt.gz (60k, freq order) → Known(word) case-insensitive + possessive/plural leniency. B. internal/spell: BK-tree from dict → Suggest(word,max) ranked by edit-distance then frequency rank. C. internal/spell: personal dict ~/.config/glint/dict.txt load+Add (append+in-memory), Known unions it. D. editor Span.Wavy + theme.Spell red color; renderSpans/renderSpansCursor emit undercurl SGR (4:3 + 58:2 color), preserve markup-visible invariant; degrade gracefully. E. editor spellcheck pass: mark misspelled prose words Wavy; skip code fences/inline code/URLs/wikilinks/link targets/frontmatter values/headings markup + whole-doc when codeFile!=''; viewport-only + word→ok cache invalidated on add; session toggle. F. app popup mode (Alt+; + mouse click on flagged word): 5 suggestions + Toggle + Add + Ignore; arrows/number+Enter apply→replace word, dirty, clear underline; Esc dismiss. G. config spellcheck=auto|on|off; default ON md/txt/unnamed, OFF code (share TASK-018 ext map).
Implementation Notes
Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green.
Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green.
Slice F/G done (commits 1221e42d, 1d757ec1): config spellcheck=auto|on|off + DictPath, dict loaded at app startup (embedded + personal), Alt+; + mouse-click-on-flagged-word open the popup (suggestions 1-9 / a Add / i Ignore / t Toggle, Esc), toggle-only popup when nothing flagged, glint -c/-h/help-overlay/README/CHANGELOG documented incl. tmux undercurl passthrough. Smoke test confirms App.View emits 4:3+58:2 SGR. All 7 ACs met. Full suite + vet + build green; binary +~250KB (embedded gz).